iT邦幫忙

2025 iThome 鐵人賽

DAY 6
0
佛心分享-SideProject30

Road To Full-Stack:前端轉全端的 Instagram 挑戰系列 第 6

Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 6

  • 分享至 

  • xImage
  •  

本日完成內容

改採 App Router 的動態語系段 [locale](取代 next.config 的 i18n),整併登入/註冊路由到 /{locale}/...,並加入字典型別、語系標準化(zh-tw/zh_tw/zh 皆視為 zh-TW)。同時完成登入頁 UI + 驗證(shadcn/ui + RHF + Zod)、Theme Provider 與全域樣式、授權邏輯對語系前綴的支援,以及測試與型別整理。

心得

又看到關於依賴AI導致腦活動力降低的新聞,所以重新試了一下純手寫,只發現已經完全無法接受這樣的開發速度,或許就跟汽車發明一樣,人們跑步的能力變差了,但是會有新的開車能力被開發出來,結論是回不去了==

功能概覽

  • 路由/i18n:使用 src/app/[locale]/... 處理多語,移除 next.config i18n;加入 normalizeLocale() 容忍大小寫與連字號差異
  • 登入頁:以 shadcn/ui + React Hook Form + Zod 完成欄位驗證、錯誤提示與阻擋送出
  • UI/Theme:新增 Theme Provider、按鈕/表單/卡片元件與全域 token/色彩
  • Middleware/Auth:authorized 可去除語系前綴判斷;NextAuth 預設登入頁改為 /en/login
  • 型別整理:集中 PageResult<T>UsersCursorsrc/server/types.ts
  • 測試:新增登入頁 SSR/schema 測試;Server 整合測試預設執行(需本機 Postgres)

主要改動摘要(相對於 release/day-5)

  • 路由改造(只保留一組語系前綴路徑)

    • 新增:src/app/[locale]/(public)/login/page.tsx
    • 新增:src/app/[locale]/(public)/signup/page.tsx(暫為占位)
    • 移除:src/app/(public)/login/page.tsxsrc/app/(public)/signup/page.tsx
  • 表單與 UI

    • src/app/[locale]/(public)/login/_components/LoginForm.tsx:RHF + Zod 驗證、錯誤訊息、連結註冊
    • shadcn/ui:src/components/ui/button.tsxsrc/components/ui/card.tsxsrc/components/ui/form.tsxsrc/components/ui/input.tsxsrc/components/ui/label.tsx
    • 置中公共頁:src/app/(public)/layout.tsx
    • Theme Provider:src/components/theme-provider.tsx,全域樣式:src/app/globals.css
  • i18n 與字典

    • 主入口:src/lib/i18n.tsgetMessagesderiveLocaleFromPathnamenormalizeLocale
    • 型別:src/lib/i18n/types.tsLocaleMessages
    • 字典:src/lib/i18n/locales/en.tssrc/lib/i18n/locales/zh-TW.ts
    • 配置:next.config.ts 改為空設定(移除 i18n,避免與 [locale] 路由衝突)
  • Auth 與 Middleware

    • src/server/auth/index.tsauthorized 先去除 /{locale} 再判斷公開/保護路由;pages.signIn 改為 /en/login
  • 型別與服務層

    • src/server/types.ts:新增 PageResult<T>UsersCursor
    • src/server/users.tssrc/server/repos/usersRepo.ts:改用集中型別
  • 測試

    • tests/client/login.test.tsx:SSR smoke + schema 驗證
    • tests/server/api.users.*.test.ts:仍通過
    • tests/server/service.users.test.ts:預設會執行並連線 Postgres(不再自動略過)

i18n 設計補充

  • URL:/en/login/zh-TW/login(亦接受 /zh-tw/login 等變體)
  • normalizeLocale:將 zh-tw/zh_tw/zh 視為 zh-TWen/en-* 視為 en
  • 字典型別 LocaleMessages:確保各語系鍵值一致,避免遺漏

重點程式碼(片段)

  • i18n 型別定義(src/lib/i18n/types.ts
export type LocaleMessages = {
  title: string;
  email: string;
  password: string;
  submit: string;
  signupPrefix: string;
  signupLink: string;
  errors: {
    required: string;
    email: string;
    passwordMin: string;
    invalidCredentials: string;
  };
};
  • i18n 主入口與標準化(src/lib/i18n.ts
import enMessages from './i18n/locales/en';
import zhTwMessages from './i18n/locales/zh-TW';
import type { LocaleMessages } from './i18n/types';

export const locales = ['en', 'zh-TW'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';

const messagesByLocale: Record<Locale, LocaleMessages> = {
  en: enMessages,
  'zh-TW': zhTwMessages,
};

export function normalizeLocale(input: string | null | undefined): Locale {
  const s = (input ?? '').toString().trim().toLowerCase();
  if (!s) return defaultLocale;
  if (s === 'en' || s.startsWith('en-')) return 'en';
  if (s === 'zh' || s === 'zh-tw' || s === 'zh_tw' || s === 'zhtw') return 'zh-TW';
  return defaultLocale;
}

export function getMessages(locale: string): LocaleMessages {
  const norm = normalizeLocale(locale);
  return messagesByLocale[norm] ?? messagesByLocale[defaultLocale];
}

export function deriveLocaleFromPathname(pathname: string): Locale {
  const first = pathname.split('/').filter(Boolean)[0];
  return normalizeLocale(first);
}
  • 登入頁(src/app/[locale]/(public)/login/page.tsx
import LoginForm from '@/app/[locale]/(public)/login/_components/LoginForm';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { getMessages, normalizeLocale, type Locale } from '@/lib/i18n';

export default function LoginPage({ params }: { params: { locale: string } }) {
  const locale = normalizeLocale(params.locale) as Locale;
  const t = getMessages(locale);
  return (
    <div className="mx-auto grid min-h-[calc(100dvh-4rem)] w-full max-w-sm place-items-center px-4 py-8">
      <Card className="w-full">
        <CardHeader>
          <CardTitle>{t.title}</CardTitle>
          <CardDescription />
        </CardHeader>
        <CardContent>
          <LoginForm locale={locale} />
        </CardContent>
      </Card>
    </div>
  );
}
  • 登入表單(src/app/[locale]/(public)/login/_components/LoginForm.tsx
"use client";
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { deriveLocaleFromPathname, getMessages, type Locale } from '@/lib/i18n';

export type LoginFields = { email: string; password: string };

export default function LoginForm({ locale: forcedLocale }: { locale?: Locale }) {
  const pathname = usePathname();
  const locale = forcedLocale ?? deriveLocaleFromPathname(pathname ?? '/');
  const t = getMessages(locale);
  const router = useRouter();
  const [serverError, setServerError] = React.useState<string | null>(null);

  const schema = React.useMemo(() => makeLoginSchema(t.errors), [t.errors]);
  const form = useForm<LoginFields>({ resolver: zodResolver(schema), defaultValues: { email: '', password: '' } });

  const onSubmit = async (values: LoginFields) => {
    setServerError(null);
    const result = await signIn('credentials', { ...values, redirect: false });
    if (!result || result.error) return setServerError(t.errors.invalidCredentials);
    router.push('/');
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="flex w-full flex-col gap-4" noValidate>
        {serverError && (
          <div role="alert" className="text-destructive text-sm">{serverError}</div>
        )}
        <FormField name="email" control={form.control} render={({ field }) => (
          <FormItem>
            <FormLabel>{t.email}</FormLabel>
            <FormControl>
              <Input {...field} type="email" autoComplete="email" placeholder={t.email} />
            </FormControl>
            <FormMessage />
          </FormItem>
        )} />
        <FormField name="password" control={form.control} render={({ field }) => (
          <FormItem>
            <FormLabel>{t.password}</FormLabel>
            <FormControl>
              <Input {...field} type="password" autoComplete="current-password" placeholder={t.password} />
            </FormControl>
            <FormMessage />
          </FormItem>
        )} />
        <Button type="submit" disabled={form.formState.isSubmitting}>{t.submit}</Button>
        <FormDescription>
          {t.signupPrefix} <Link href="/signup" className="underline">{t.signupLink}</Link>
        </FormDescription>
      </form>
    </Form>
  );
}

export function makeLoginSchema(errors: { required: string; email: string; passwordMin: string }) {
  return z.object({
    email: z.string().min(1, errors.required).email(errors.email),
    password: z.string().min(1, errors.required).min(8, errors.passwordMin),
  });
}
  • Auth 授權(去除語系前綴、指定預設登入頁)(src/server/auth/index.ts 節錄)
import { normalizeLocale } from '@/lib/i18n';

export const authConfig = {
  pages: { signIn: '/en/login' },
  callbacks: {
    authorized({ request, auth }) {
      const { pathname } = request.nextUrl;
      const stripLocale = (p: string) => {
        const segments = p.split('/');
        const first = segments[1];
        const norm = normalizeLocale(first);
        if (first && (norm === 'en' || norm === 'zh-TW')) return '/' + segments.slice(2).join('/');
        return p;
      };
      const core = stripLocale(pathname);
      if (core.startsWith('/login') || core.startsWith('/signup')) return true;
      if (core.startsWith('/api/auth') || pathname.startsWith('/api/auth')) return true;
      if (core.startsWith('/_next') || pathname.startsWith('/_next')) return true;
      if (core === '/favicon.ico' || pathname.includes('.')) return true;
      return !!auth?.user;
    },
  },
};
  • next.config(移除 i18n,避免與 [locale] 衝突)(next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {};
export default nextConfig;

上一篇
Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 5
系列文
Road To Full-Stack:前端轉全端的 Instagram 挑戰6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言